Glue::execute 배치 planning 수정 계획 (소유권·제네릭 경계, GlueSQL #2009)
NOTE
완료 (2026-09-20). TDD 사이클(RED → GREEN)로 진행: 이슈 본문의 재현 케이스를 테스트로 먼저 옮겨 실패를 확인한 뒤,
core/src/glue.rs의execute_with_params를 수정. 테스트는 처음에 독립 함수로 만들었다가, 이슈의 “Test placement” 지시대로 제네릭batch+ 기존 테스트 수정 구조로 다시 맞춤(memory/redb 양쪽 커버, §“테스트 방법” 참고). core 유닛 테스트 593개, memory-storage 통합 테스트 200개, CLI 테스트 13개 전부 회귀 없이 통과.cargo fmt --check,cargo clippy -- -D warnings클린. 아래 “고쳐야 하는 pseudocode”는 실제로 반영된 코드와 거의 동일함(§”✅ 실제 반영된 코드” 참고).
📌 개념
문제 — 한 번에 여러 SQL 문을 실행하면, 뒤 문장이 앞 문장의 DDL을 못 봄
CREATE TABLE S;
INSERT INTO S VALUES ('{"a": 1}');
SELECT a FROM S;이 세 문장을 한 번의 glue.execute(...) 호출에 세미콜론으로 이어 붙이면 identifier not found: a 에러가 남. 반대로 한 문장씩 세 번 나눠 호출하면 정상 동작. “여러 문장을 한 번에 실행하는 것”과 “한 문장씩 실행하는 것”의 결과가 달라지면 안 되는데 다름.
원인 — 전부 plan 하고 나서야 실행이 시작됨
core/src/glue.rs:
// execute_with_params (현재)
pub fn execute_with_params(&mut self, sql, params) -> Result<Vec<Payload>> {
let statements = self.plan_with_params(sql, params)?; // 여기서 SQL 전체를 먼저 다 plan
let mut payloads = Vec::new();
for statement in &statements {
payloads.push(self.execute_stmt(statement)?); // 그 다음에야 하나씩 실행
}
Ok(payloads)
}plan_with_params는 내부에서 parse(sql)?로 나온 모든 문장을 .map(...).collect()로 한 번에 planning함. 두 번째 문장(SELECT a FROM S)을 planning하는 시점엔 아직 첫 번째 문장(CREATE TABLE S)이 실행 전이라 스키마 맵에 S가 없음 → planning 단계에서 스키마를 보는 최적화/검증 패스들이 전부 “테이블 없음”으로 조용히 스킵되거나 잘못된 결과를 냄.
고쳐야 하는 pseudocode (작업 착수 전 계획)
이슈 본문에 정확한 방향이 이미 제시돼 있음 — plan과 execute를 문장 하나 단위로 묶어서 반복:
// core/src/glue.rs — execute_with_params 수정 방향
pub fn execute_with_params<Sql, I, P>(&mut self, sql: Sql, params: I) -> Result<Vec<Payload>>
where
Sql: AsRef<str>,
I: IntoIterator<Item = P>,
P: IntoParamLiteral,
{
// params 변환은 문장마다 반복할 필요 없이 루프 밖에서 한 번만
let params: Vec<ParamLiteral> = params
.into_iter()
.map(IntoParamLiteral::into_param_literal)
.collect();
let mut payloads = Vec::<Payload>::new();
for p in parse(sql)? {
let statement = translate_with_params(&p, ¶ms)?; // params는 빌려서(&) 씀
let plan = self.storage.plan(statement.into())?;
payloads.push(self.execute_stmt(&plan)?); // 바로 실행 → 다음 반복의 planning이 이 결과를 봄
}
Ok(payloads)
}Glue::plan(prepared-statement 진입점)이 쓰는plan_with_params는 그대로 둔다 — 이건 “실행 전에 전부 미리 계획만 해두고 싶은” 용도라 지금 동작이 맞음. 손대는 건execute_with_params뿐.- 동작 변경 하나 인지: 이제 배치 중간 문장에서 planning 에러가 나면, 그 앞 문장들은 이미 실행된 채로 끝남(이전엔 첫 문장부터 하나도 실행 안 되고 에러). 이슈 본문도 이걸 “의도된 동작 변화”로 명시함.
✅ 실제 반영된 코드
계획과 거의 동일하게 반영됨. self.execute_stmt(&plan)?을 바로 호출하는 대신 중간 변수 payload로 한 번 받은 것과, parse(sql)?을 순회 대상 변수 parsed에 먼저 바인딩한 것 정도만 계획과 다름 (for p in parsed) — 동작은 pseudocode와 완전히 동일.
core/src/glue.rs의 execute_with_params 전체 diff:
pub fn execute_with_params<Sql, I, P>(&mut self, sql: Sql, params: I) -> Result<Vec<Payload>>
where
Sql: AsRef<str>,
I: IntoIterator<Item = P>,
P: IntoParamLiteral,
{
- let statements = self.plan_with_params(sql, params)?; // SQL 전체를 먼저 다 plan
- let mut payloads = Vec::new();
- for statement in &statements {
- payloads.push(self.execute_stmt(statement)?); // 그 다음에야 하나씩 실행
- }
- Ok(payloads)
+ let parsed = parse(sql)?;
+ let params: Vec<ParamLiteral> = params
+ .into_iter()
+ .map(IntoParamLiteral::into_param_literal)
+ .collect();
+
+ let mut payloads = Vec::<Payload>::new();
+ for p in parsed {
+ let statement = translate_with_params(&p, ¶ms)?;
+ let plan = self.storage.plan(statement.into())?;
+ let payload = self.execute_stmt(&plan)?;
+ payloads.push(payload);
+ }
+
+ Ok(payloads)
}plan_with_params(내부에서 전체를 .map(...).collect()로 한 번에 plan)는 그대로 두고, execute_with_params만 “translate → plan → 즉시 execute”를 문장 하나 단위로 반복하도록 새로 작성함.
왜 깨지는가 ① — 스키마리스(schemaless) 테이블 동작 원리
재현 케이스의 CREATE TABLE S;는 컬럼 정의를 빠뜨린 실수가 아니라, 스키마리스 테이블을 만들겠다는 선언임. GlueSQL은 MongoDB 컬렉션처럼 “행 하나 = JSON 문서 하나”인 테이블을 지원함.
1) 컬럼 정의의 유무는 Option으로 표현된다
// core/src/data/schema.rs:33
pub column_defs: Option<Vec<ColumnDef>>,| 값 | 의미 |
|---|---|
Some(vec![...]) | 스키마풀(schemaful) — 일반 SQL 테이블 |
None | 스키마리스 — 행은 JSON 문서 하나 |
2) _doc은 스키마에 저장되지 않는다 (가장 헷갈리는 지점)
CREATE TABLE S;를 실행하면 스토리지에는 Schema { column_defs: None, .. }만 저장됨. "_doc"이라는 문자열은 어디에도 기록되지 않음. 그냥 소스코드의 상수임.
// core/src/data.rs:11
pub(crate) const SCHEMALESS_DOC_COLUMN: &str = "_doc";planner와 executor가 각자 column_defs를 보고 “None이네? 그럼 _doc 쓰자”라고 매번 판단하는 규칙일 뿐.
// core/src/executor/fetch.rs:60-61
column_defs.as_deref().map_or_else(
|| vec![SCHEMALESS_DOC_COLUMN.to_owned()], // None이면 _doc
|columns| ..., // Some이면 진짜 컬럼들
)3) INSERT — JSON 문자열이 Value::Map으로 파싱된다
// core/src/executor/insert.rs:33
let rows = match column_defs {
Some(column_defs) => schemaful::fetch_rows(...), // 컬럼 개수/타입 검증
None => schemaless::fetch_rows(storage, source), // JSON 파싱
};// core/src/executor/insert/schemaless.rs:48-55
let map = match values.into_iter().next() {
Some(Value::Map(map)) => map,
Some(Value::Str(value)) => BTreeMap::parse_json_object(&value)?, // ← '{"a": 1}'
Some(value) => return Err(EvaluateError::MapOrStringValueRequired(...)),
};
Ok(vec![Value::Map(map)])값이 2개 이상이면 OnlySingleValueAcceptedForSchemalessRow 에러. 즉 INSERT INTO S VALUES (1, 2)는 불가.
저장되는 실제 행:
Row {
columns: ["_doc"], // ← "a"라는 컬럼은 존재하지 않음
values: [Value::Map({"a": I64(1)})],
}C로 비유하면 column_defs가 NULL이냐 아니냐로 struct Record*(고정 레이아웃) vs hashmap*(동적 키) 두 저장 전략을 고르는 것.
4) SELECT — planner가 a를 _doc['a']로 바꿔치기한다
// core/src/planner/schemaless/query.rs:405-412
ExprPlan::Identifier(ident) => {
if state.rewrite_unqualified_identifiers {
*e = ExprPlan::ArrayIndex {
obj: Box::new(ExprPlan::Identifier(SCHEMALESS_DOC_COLUMN.to_owned())),
indexes: vec![ExprPlan::Literal(Literal::QuotedString(ident.to_owned()))],
};
}
}재작성되는 건 expr뿐이고 label은 그대로 — 그래서 결과 컬럼명이 _doc이 아니라 a로 나옴.
SelectItemPlan::Expr {
expr: ExprPlan::ArrayIndex { // 평가용 — _doc['a']
obj: Box::new(ExprPlan::Identifier("_doc")),
indexes: vec![ExprPlan::Literal(Literal::QuotedString("a"))],
},
label: "a", // 출력용 — 사용자가 쓴 이름 유지
}TIP
plan_schemaless는 “사용자가 쓴 논리적 컬럼명”을 “물리적 저장 형태(_doc맵 조회)“로 번역하는 계층이고, 그 번역에 필요한 사전(dictionary)이Schema임. 사전이 없으면 번역이 통째로 생략됨.
5) 배치에서는 이 번역이 통째로 생략된다
// core/src/planner/schemaless.rs
pub fn plan<S: BuildHasher>(schema_map: &..., statement: StatementPlan) -> Result<StatementPlan> {
if !schema_map.values().any(|schema| schema.column_defs.is_none()) {
return Ok(statement); // ← 배치에서는 여기로 빠짐
}
validate_statement(schema_map, &statement)?;
Ok(transform_statement(schema_map, statement))
}S가 아직 생성 전이라 schema_map이 빈 맵 → 빈 맵의 .any(..)는 false → !false == true → early return. transform_statement가 호출조차 안 되고 AST가 원본 그대로 반환됨.
| 스토리지 응답 | planner가 아는 것 |
|---|---|
Some(Schema { column_defs: None }) | S는 스키마리스 → _doc['a']로 재작성 |
Some(Schema { column_defs: Some(..) }) | S는 스키마풀 → 그대로 둠 |
None (배치에서의 상황) | 모름 → 그대로 둠 |
SQL 문자열 SELECT a FROM S만 봐서는 두 경우를 구분할 수 없음. 스키마를 읽어야만 알 수 있음.
그리고 재작성 안 된 Identifier("a")를 실행 시점에 평가하면:
// core/src/data/row.rs:13
pub fn get_value(&self, ident: &str) -> Option<&Value> {
self.columns.iter().position(|column| column == ident) // ["_doc"]에서 "a" 찾기 → None
.and_then(|index| self.values.get(index))
}→ RED 단계에서 본 Err(Evaluate(IdentifierNotFound("a"))) 그대로.
왜 깨지는가 ② — INSERT는 왜 멀쩡한가 (plan은 best-effort다)
CREATE TABLE X (id INTEGER); ... INSERT INTO X VALUES (1); ...이것도 plan 시점엔 X가 없는데 왜TableNotFound가 안 나는가?
TableNotFound가 만들어지는 자리는 전부 executor이고, executor는 CREATE가 실행을 마친 뒤에 돌기 때문.
// core/src/executor/insert.rs:29-31
storage.fetch_schema(table_name)?
.ok_or_else(|| InsertError::TableNotFound(table_name.to_owned()))?;[Phase A] 5개 전부 plan ← storage에 X,Y 없음. 하지만 아무도 에러를 안 냄
[Phase B] 5개 전부 execute
exec(CREATE X) → X 생성
exec(INSERT X) → fetch_schema("X") → Some ✅ 정상
planner의 모든 패스는 스키마가 없으면 에러가 아니라 아무 일도 안 하는 쪽으로 퇴화(degrade) 함:
| 패스 | 스키마 없을 때 |
|---|---|
fetch_schema_map | schema.map_or_else(HashMap::new, ..) → 빈 맵 |
validate | contextualize_query → None → 검사 스킵 |
plan_schemaless | any(column_defs.is_none()) 거짓 → early return |
plan_primary_key / plan_hash_join | 매칭 스키마 없음 → 원본 그대로 |
core/src/planner/schema.rs 테스트에 이 의도가 주석으로 박혀 있음 — // ignore rather than returning error.
그래서 두 재현 케이스의 증상이 서로 다름:
| case 1 (schemaless) | case 2 (JOIN) | |
|---|---|---|
| 증상 | 잘못된 에러 (IdentifierNotFound("a")) | 에러가 안 남 (나야 하는데) |
| 누락된 패스 | plan_schemaless 재작성 | validate 모호성 검사 |
| 기대 결과 | Payload::Select { labels: ["a"], .. } | Err(ColumnReferenceAmbiguous("id")) |
case 2에서 SELECT id FROM X JOIN Y는 X.id/Y.id가 둘 다 있어 모호하므로 에러가 나야 하는데, schema_map이 비어 있어 검증 자체가 건너뛰어지고 조용히 Ok가 돌아옴.
// core/src/planner/validate.rs
if let SelectItemPlan::Expr { expr: ExprPlan::Identifier(ident), .. } = select_item
&& let Some(context) = contextualize_query(schema_map, query) // ← 빈 맵이면 None
{
context.validate_duplicated(ident)?; // ← 아예 실행 안 됨
}IMPORTANT
이 이슈가 까다로운 이유: 스키마 부재가 에러로 터졌다면 금방 드러났을 텐데, best-effort 설계 탓에 조용히 틀린 동작으로 흘러감. 그리고 한 번
.collect()로 굳어버린 plan은 나중에 스키마가 생겨도 다시 고쳐지지 않음 — 이것이 plan을 루프 안으로 옮겨야 했던 이유.
실측 확인 (임시 테스트로 직접 찍어봄)
[수정 전]
BATCHED = Ok([Create, Create, Insert(1), Insert(1),
Select { labels: ["id"], rows: [[I64(1)]] }]) ← 조용히 성공
ONE-BY-ONE = Err(Planner(ColumnReferenceAmbiguous("id")))
[수정 후]
BATCHED = Err(Planner(ColumnReferenceAmbiguous("id"))) ← 일치
ONE-BY-ONE = Err(Planner(ColumnReferenceAmbiguous("id")))
그럼 executor가 모호성을 못 잡는 게 버그 아닌가? (설계 논의)
모호성 검사 자체가 executor에 없음. ColumnReferenceAmbiguous는 core/src/planner/validate.rs:94와 core/src/planner/error.rs:8 딱 두 곳에만 존재하고 executor에는 단 한 건도 없음. executor는 검사 대신 먼저 찾은 쪽을 그냥 채택함.
// core/src/executor/context/row_context.rs:46-48
Self::Bridge { left, right } => {
left.get_value(target).or_else(|| right.get_value(target))
}or_else는 왼쪽이 None일 때만 오른쪽을 봄(short-circuit). JOIN 컨텍스트에서 id를 찾으면 X의 값이 먼저 걸리고 반환됨 — Y에도 있다는 걸 확인조차 안 함.
그래도 executor 잘못은 아니라고 보는 이유:
- 모호성은 원래 실행 전 에러가 맞음. SQL 표준·PostgreSQL 모두 파스/분석(analyze) 단계에서 잡음(
ERROR: column reference "id" is ambiguous). 행이 0건이어도 나야 하는 에러 = 데이터가 아니라 쿼리 자체가 잘못된 것 → 스키마를 아는 planner의 책임. - 핫 패스 비용.
get_value는 행마다 × 표현식마다 호출됨. 모호성을 보려면 매번 양쪽을 전부 훑어야 함 → 100만 행이면 스키마만 보면 1번에 끝날 판정을 100만 번 반복. - executor는 “planner가 이미 검증했다”를 전제(precondition) 로 동작함. 계약 자체는 합리적이고, 문제는 그 전제가 깨졌는데 아무도 몰랐던 것.
| 단계 | 하는 일 | id가 양쪽에 있을 때 |
|---|---|---|
planner validate | 스키마를 보고 모호성 판정 | Err(ColumnReferenceAmbiguous) |
executor get_value | 실제 행에서 값 꺼내기 | or_else로 X의 값 채택 |
진짜 문제는 fetch_schema_map이 “스키마를 못 찾았다”와 “검사해봤는데 문제없다”를 둘 다 Ok로 구분 없이 반환한다는 점. 개선 방향 후보:
| 방안 | 내용 | 평가 |
|---|---|---|
| executor에 검사 추가 | Bridge에서 양쪽 다 확인 | 핫 패스 비용 + 늦은 발견 + 로직 중복 — 별로 |
| plan 시 ordinal 바인딩 | Identifier("id") → 슬롯 번호로 확정 | Postgres의 Var{varno, varattno} 방식. executor가 이름 해석을 아예 안 하게 되어 이 버그 클래스가 구조적으로 소멸 |
| 스키마 부재를 에러로 승격 | fetch_schema가 None이면 통과시키지 않음 | 단순하지만 기존 // ignore rather than returning error 의도를 뒤집음 |
#2009 수정으로 구멍은 실질적으로 막혔지만, “planner가 스키마를 못 찾아도 Ok”라는 설계는 남아 있음 → 별도 이슈 제안 대상(§“후속 이슈 제안” 참고).
패스별 영향도 — 정확성 붕괴 vs 성능 저하
스키마가 없을 때 모든 패스가 똑같이 위험한 건 아님. CREATE TABLE T (a INTEGER PRIMARY KEY); INSERT INTO T VALUES (1); SELECT a FROM T;는 배치로 돌려도 멀쩡함. 실제 plan을 찍어서 비교:
[스키마 없음] SELECT a FROM T access: FullScan
[스키마 있음] SELECT a FROM T access: FullScan → 완전 동일 ✅
[스키마 없음] SELECT a FROM T WHERE a=1 access: FullScan + Filter{a Eq 1}
[스키마 있음] SELECT a FROM T WHERE a=1 access: PrimaryKey{ expr: 1 } → 다름(성능만)
[배치 실행] Ok([Create, Insert(1), Select { labels: ["a"], rows: [[I64(1)]] }])
| 패스 | 스키마 없으면 | 성격 |
|---|---|---|
plan_schemaless | IdentifierNotFound | 정확성 붕괴 |
validate | 에러가 안 남 | 정확성 붕괴 (조용함) |
plan_primary_key / plan_hash_join | 전체 스캔으로 퇴화 | 성능 저하만 |
plan_primary_key는 WHERE가 PK 컬럼을 상수와 비교할 때 전체 스캔을 키 조회로 바꾸는 최적화일 뿐 결과는 같음. #2009가 정확성 버그인 건 앞의 두 패스 때문이고, PRIMARY KEY 예제는 애초에 영향권 밖.
TIP
Glue::plan(sql)이pub이라서 실행 없이Vec<StatementPlan>만 뽑아{:?}로 찍어볼 수 있음. planner 동작을 눈으로 확인할 때 유용.
손으로 확인해볼 포인트 (소유권 관점)
- 기존 코드는
plan_with_params가params: I를 통째로 소비해서Vec<ParamLiteral>을 한 번만 만들고 끝. 고친 코드도 루프 진입 전에 딱 한 번Vec<ParamLiteral>로 변환해두고, 루프 안에서는¶ms로 매 반복 “빌려서”translate_with_params에 넘김 — 왜 루프 안에서 매번 새로 만들면 안 되는지(애초에I: IntoIterator는 한 번만 소비 가능) 생각해보기 parse(sql)?가 반환하는 값을for p in ...로 바로 순회 — 기존처럼Vec에 다 모았다가 순회하는 것과 무엇이 다른지(모아두지 않고 바로바로 쓴다는 점에서 “즉시 실행”이 가능해짐)
테스트 방법 (실제로 진행한 방식)
WARNING
처음엔 기존 테스트를 안 건드리고 독립된
memory_batch테스트를 새로 추가하는 방식으로 갔음(회귀 리스크 최소화 의도). 나중에 이슈 본문의 “Test placement” 섹션을 다시 확인해 보니 메인테이너가 테스트 배치 방법을 표까지 만들어 구체적으로 지정해 놨고, 우리 구조가 거기서 벗어나 있었음 — 특히 redb 경로에서 batch 케이스가 전혀 검증되지 않는 실질적 커버리지 손실이 있었음. 결국 이슈 지시대로 기존 테스트를 수정하는 구조로 다시 맞춤. 아래는 최종 형태.
왜 새 테스트를 따로 만들면 안 되는가
redb_basic이 data/redb_basic 경로를 하드코딩하고 remove_file로 시작함. 같은 경로를 여는 별도 테스트를 만들면 cargo test의 병렬 실행에서 경합(race) 이 남(한 테스트가 파일을 지우는 동안 다른 테스트가 그 파일을 열고 있을 수 있음). 이슈가 sled에 대해 경고한 내용이 redb에도 그대로 적용됨. 다른 경로를 쓰면 경합은 피하지만 디스크 스토리지 인스턴스를 하나 더 띄우는 낭비. → 기존 테스트 함수 안에서 이어 호출하는 게 정답.
최종 구조 — pkg/rust/tests/glue.rs
// 1) 시그니처를 값 → 가변 참조로 (glue 하나를 두 시나리오가 공유 가능해짐)
-fn basic<T: GStore + GStoreMut + Planner>(mut glue: Glue<T>) {
+fn basic<T: GStore + GStoreMut + Planner>(glue: &mut Glue<T>) {
// 2) memory 전용이던 memory_batch를 제네릭 batch 함수로 승격
fn batch<T: GStore + GStoreMut + Planner>(glue: &mut Glue<T>) {
use gluesql::core::planner::PlannerError;
// a table created earlier in the same call must be visible to the planner
assert_eq!(
glue.execute(
"DROP TABLE IF EXISTS batch_doc;
CREATE TABLE batch_doc;
INSERT INTO batch_doc VALUES ('{\"a\": 1}');
SELECT a FROM batch_doc;"
),
Ok(vec![
Payload::DropTable(0),
Payload::Create,
Payload::Insert(1),
Payload::Select {
labels: vec!["a".to_owned()],
rows: vec![vec![Value::I64(1)]],
},
])
);
// validation must see schemas created earlier in the same call
assert_eq!(
glue.execute(
"DROP TABLE IF EXISTS batch_x;
DROP TABLE IF EXISTS batch_y;
CREATE TABLE batch_x (id INTEGER);
CREATE TABLE batch_y (id INTEGER);
SELECT id FROM batch_x JOIN batch_y ON batch_x.id = batch_y.id;"
),
Err(PlannerError::ColumnReferenceAmbiguous("id".to_owned()).into())
);
}
// 3) 두 테스트를 리네임하고 시나리오 둘 다 호출 (이제 basic만 돌리는 게 아니므로)
-fn memory_basic() { ...; basic(glue); }
+fn memory() { ...; basic(&mut glue); batch(&mut glue); }
-fn redb_basic() { ...; basic(glue); }
+fn redb() { ...; basic(&mut glue); batch(&mut glue); }DROP TABLE IF EXISTS가 붙은 이유 — glue 공유의 부작용
Glue<T>는 스토리지를 소유한 객체라 인스턴스 하나 = DB 하나. &mut로 넘기면 소유권은 호출부가 계속 쥐고 있고 basic/batch가 차례로 빌려 쓰므로 RedbStorage 인스턴스도 DB 파일도 하나로 끝남. 대신 앞 시나리오가 만든 테이블이 뒤 시나리오에 그대로 남음. basic이 이미 DROP TABLE IF EXISTS api_test로 시작하는 것과 같은 패턴.
WARNING
“redb는 파일로 남으니 DROP으로 청소한다”는 설명은 정확하지 않음. 파일 청소는
DROP이 아니라 테스트 함수 맨 위에 원래부터 있던let _ = remove_file(path);가 함. 실제로 재실행해봐도 매번 파일을 지우고 시작하므로 디스크에 이전 상태가 남아 오는 일은 없음(2회 연속 실행 모두 memory/redb 통과). 게다가pkg/rust/data/는.gitignore:3에 이미 등록돼 있음.
Payload::DropTable(n)의 n은 “실제로 지워진 테이블 개수” — 청소한 게 아니라 청소할 게 없었음을 증명하는 값임. 실측:
없을 때 DROP TABLE IF EXISTS t => Ok([DropTable(0)])
있을 때 DROP TABLE IF EXISTS t => Ok([DropTable(1)])
그래서 기대값에 DropTable(0)을 박아두는 것은 “이 시점에 그 테이블이 존재하지 않는다”를 단언(assert)하는 것이지, 남은 걸 치우는 동작이 아님. DROP 접두사가 실제로 하는 역할은 셋:
- 자기 완결성 — 각 시나리오의 SQL 블록이 앞선 상태를 전제하지 않고 혼자 읽혀도 말이 됨
- 공유
Glue격리 —basic이 만든api_test가 살아 있는 상태에서batch가 돌기 때문에, 이름 충돌이 생기면 조용히 이상 동작하는 대신 시나리오 경계를 명시 - 큰 소리로 실패하기 — 만에 하나
remove_file이 실패하거나 테이블이 살아남으면DropTable(0)단언이DropTable(1)로 깨지면서 바로 드러남. 조용히 통과하는 대신 터지게 만드는 장치
부수적으로 드러난 동작 변경 — plan 실패 시 앞선 부수효과가 남는다
이번 수정은 버그만 고친 게 아니라 실패 시 의미론(semantics)도 바꿈. 수정 전에는 모든 문장을 먼저 plan했으므로 뒤쪽 문장의 plan이 실패하면 앞쪽 문장이 아예 실행되지 않았음(사실상 all-or-nothing). 수정 후에는 앞 문장이 이미 실행된 뒤에 뒤 문장의 plan이 실패하므로 앞선 부수효과가 그대로 남음.
배치 실행 전에 P, Q 테이블이 미리 존재하는 상태에서 실측:
CREATE TABLE A (id INTEGER);
INSERT INTO A VALUES (1);
SELECT id FROM P JOIN Q ON P.id = Q.id; -- plan 단계에서 실패 배치 결과 이후 SELECT id FROM A
수정 전 Err(ColumnReferenceAmbiguous) → Err(Fetch(TableNotFound("A"))) ← A가 안 만들어짐
수정 후 Err(ColumnReferenceAmbiguous) → Ok([... rows: [[I64(1)]]]) ← A가 남아 있음
이건 “plan을 실행 직전으로 미룬다”는 변경의 직접적·불가피한 귀결이고, 애초에 GlueSQL의 execute는 트랜잭션 경계를 보장하지 않으므로(문장 단위 실행) 회귀가 아니라 의도된 트레이드오프. 다만 리뷰어가 모르고 지나가면 안 되는 부분이라 PR 본문에 Behavior change 문단으로 명시함.
진행 순서 (TDD RED → GREEN)
- RED: batch 테스트를 먼저 작성 → 수정 전 코드로 실행하면 첫
assert_eq!에서Err(Evaluate(IdentifierNotFound("a")))로 실패. 이슈에 보고된 증상과 정확히 일치(테스트가 올바른 것을 검증한다는 근거) - GREEN:
core/src/glue.rs의execute_with_params를 위 ”✅ 실제 반영된 코드”로 수정 - 구조 리팩터링 후 재검증:
core/src/glue.rs를git checkout으로 되돌려 다시 실행 →memory/redb양쪽 다 RED 재현 확인, 그 뒤 수정본 복원 → 양쪽 다 GREEN cargo test -p gluesql --test glue→memory,redb2개 통과- 전체 회귀 검증: core 유닛 테스트 593개, memory-storage 통합 테스트 197개, redb 17개 — 전부 통과
cargo fmt --all -- --check,cargo clippy --all-targets -- -D warnings— 클린
검증 명령어 (최종 실행 결과)
# 1) 이번 수정과 직결된 테스트 (가장 빠름, 먼저 이것부터)
cargo test -p gluesql --test glue # 2 passed (memory, redb)
# 2) core 유닛 테스트 (planner/executor 회귀 확인)
cargo test -p gluesql-core # 593 passed
# 3) 스토리지 통합 테스트
cargo test -p gluesql_memory_storage -p gluesql-redb-storage # 197 + 17 passed
# 4) 포맷 + 린트 (CI에서 막히는 단골)
cargo fmt --all -- --check
cargo clippy -p gluesql-core -p gluesql --all-targets -- -D warningsWARNING
패키지 이름의 구분자가 크레이트마다 다름. memory만 언더스코어(
gluesql_memory_storage), 나머지는 하이픈(gluesql-redb-storage).-p gluesql-memory-storage로 쓰면did not match any packages에러. 실패 상세를 보려면-- --nocapture.cargo test --workspace는 mongo/redis가 실제 서버를 요구해서 로컬에서 실패할 수 있으므로 위 4개로 충분함.
NOTE
이슈 본문이 낡은 부분: 이슈는
sled_basic/sled기준으로 쓰여 있지만, 이 저장소는 그사이 #2013에서 sled를 deprecate하고 redb로 교체함. 현재 파일에는redb_basic만 있고sled_basic은 없으므로 이 부분만redb로 치환해서 반영함.
배울 Rust 개념
- 소유권과 참조:
Vec<ParamLiteral>을 루프 밖에서 한 번 소유(own)하고 루프 안에서는 참조(¶ms)로만 빌려쓰는 패턴 mutvs&mut는 다른 축: 소유권 이동 여부는&가 붙었느냐로 정해지고,mut은 수정 가능 여부만 정함.mut glue: Glue<T>도 소유권은 똑같이 가져감(그래서 호출 후 재사용 불가) — 테스트 리팩터링에서 고친 건mut을 뗀 게 아니라&를 붙인 것. 파라미터 위치의mut은 타입의 일부가 아니라서 호출자에게 보이지도 않음(fn f(x: T)와fn f(mut x: T)의 시그니처는 동일)- 트레잇과 제네릭 경계:
Glue<T: GStore + GStoreMut + Planner>처럼 여러 트레잇을 한 타입 파라미터에 동시에 요구하는 경계, 테스트 헬퍼 함수도 같은 경계를 그대로 반복 - 이터레이터:
parse(sql)?(문장들의 iterator)를Vec로 모으지 않고for루프로 즉시 소비하면서 매 항목마다 부수효과(실행)를 내는 스타일 — “모아서 처리” vs “받는 대로 처리”의 차이 Option<T>로 “상태의 종류”를 표현하기:column_defs: Option<Vec<ColumnDef>>에서None이 “컬럼이 0개”가 아니라 “스키마리스 테이블”이라는 별개의 모드를 뜻함. C였다면 포인터NULL+ 별도 플래그로 표현했을 것을 타입 하나로 압축.map_or_else로 두 모드를 분기하는 패턴이 코드 전반에 반복됨(fetch.rs,execute.rs,insert.rs)- AST 재작성(
&mut방문자 패턴):visit_mut_expr(expr, &mut |e| { *e = ... })— 클로저가&mut ExprPlan을 받아 노드를 제자리에서 통째로 교체.*e = ExprPlan::ArrayIndex {..}처럼 역참조 대입으로 값을 갈아끼우는 게 가능한 이유(가변 참조는 그 자리에 쓰기 권한을 가짐)
🚀 PR 준비
커밋
브랜치 fix/multi-sql (base: main @ 72b9b510), 커밋 4d656a13.
Plan each statement right before executing it
Batched execute planned every statement up front, so statements could
only ever see the schema as it was before the batch began. A CREATE
TABLE earlier in the same call was invisible to later statements,
making schema-dependent planner passes silently degrade to no-ops.
Closes #2009
변경 파일은 core/src/glue.rs, pkg/rust/tests/glue.rs 2개만 스테이징. 로컬 도구 설정(.gitignore의 graft 캐시 항목, .claude/, .mcp.json, docs/superpowers/)은 이슈와 무관하므로 제외.
WARNING
git add -A를 쓰면 로컬 도구 설정이 통째로 딸려 들어감. 파일명을 명시해서 스테이징할 것.
PR 본문 컨벤션 (저장소에 템플릿 파일은 없음)
.github/에 PULL_REQUEST_TEMPLATE이 없어서 최근 머지된 PR들의 관행을 따름. #2001, #2012, #2013 공통 구조:
## Summary
- 동사로 시작하는 불릿 3~5개 (무엇을 why와 함께)
## Testing
- 실제로 돌린 명령어 나열
Closes #2009- 제목은 명령형·대문자 시작·마침표 없음 (
Add PostgreSQL-style regex operators,Deprecate Sled storage) ## Context섹션은 배경 설명이 필요할 때만 추가(#2012 사례)- 머지 후 CodeRabbit이
## Summary by CodeRabbit을 자동으로 덧붙임 — 직접 쓸 필요 없음 - 클린하지 않은 린트가 있으면 그 사실과 이유를 본문에 명시하는 것이 이 저장소 관행(#2001이 pre-existing lint 실패를 그렇게 적어둠)
템플릿·한영 대응 표현·제출 전 체크리스트는 별도 노트 참고 → (오픈소스) PR 본문 작성 템플릿과 컨벤션(GlueSQL 실전 사례)
최종 PR 본문 (한국어 초안)
내가 먼저 한국어로 쓰고, 빠진 내용을 보강한 뒤 영어로 옮기는 순서로 진행함. 내가 놓쳤던 3가지: ①
plan_with_params를 안 건드렸다는 Scope 명시, ② 위의 동작 변경(실패 시 부수효과 잔존), ③ 이슈 본문이 sled 기준이라 redb로 치환했다는 각주.
**요약**
연속된 SQL을 `execute()`로 한 번에 넘길 때, 기존에는 모든 문장을 먼저 plan한 뒤
실행했습니다. 그래서 모든 문장이 **배치 시작 시점의 스키마**만 볼 수 있었고,
같은 호출 안에서 앞서 만든 `CREATE TABLE`이 뒤 문장에게는 보이지 않았습니다.
스키마에 의존하는 planner 패스들이 에러 없이 조용히 no-op으로 퇴화합니다.
plan을 **각 문장의 실행 직전**으로 옮겨서, 문장 하나가 바로 앞 문장까지의
스키마 변경을 반영한 상태로 plan되게 했습니다.
**변경 사항**
- `core/src/glue.rs` — `execute_with_params`가 `parse` → (문장별) `translate` →
`plan` → `execute` 순으로 돌도록 변경. 파라미터는 루프 진입 전에 한 번만
`Vec<ParamLiteral>`로 만들어 두고 루프 안에서는 빌려 씀
- `pkg/rust/tests/glue.rs` — memory 전용이던 batch 테스트를 제네릭 함수로 올려
memory/redb 양쪽에서 돌게 함. `basic`도 `Glue<T>` → `&mut Glue<T>`로 바꿔
한 인스턴스를 두 시나리오가 공유
**범위(Scope)**
`plan_with_params`는 그대로 뒀습니다. 실행하지 않고 plan만 반환하는 공개 API라
"실행 직전 plan"이라는 개념 자체가 적용되지 않습니다.
**동작 변경**
이제 앞 문장이 실행된 뒤에 뒤 문장의 plan이 실패할 수 있으므로, **앞선 문장의
부수효과가 남습니다.** 기존에는 plan을 전부 먼저 했기 때문에 사실상
all-or-nothing이었습니다. `execute`는 트랜잭션 경계를 보장하지 않으므로
의도된 트레이드오프지만, 명시해 둡니다.
**테스트**
- [x] `cargo test -p gluesql --test glue` (memory, redb)
- [x] `cargo test -p gluesql-core` (593 passed)
- [x] `cargo test -p gluesql_memory_storage -p gluesql-redb-storage` (197 + 17)
- [x] `cargo fmt --all -- --check`
- [x] `cargo clippy -p gluesql-core -p gluesql --all-targets -- -D warnings`
수정 전 코드로 되돌려 실행하면 memory/redb 양쪽에서 새 테스트가 실패하는 것을
확인했습니다(RED 재현).
기대값의 `Payload::DropTable(0)`은 청소가 아니라 **그 시점에 테이블이 없음을
단언**하는 값입니다(`0` = 실제로 지워진 테이블 수). 하나의 `Glue`를 두 시나리오가
공유하므로 각 시나리오가 자기 전제를 스스로 세우게 했습니다.
참고: 이슈 본문은 `sled_basic` 기준이지만 #2013에서 sled가 deprecate되어
현재는 redb만 있으므로 redb에 반영했습니다.
Closes #2009영어 최종본 — 제목
Plan each statement right before executing it
번역할 때 주의한 것: 한국어 초안의 “~하게 했습니다”류를 전부 능동·현재형 평서문으로 폈고(“Move planning to just before each statement executes”), 섹션명은 이 저장소에서 실제로 머지된 PR들이 쓰는 **Summary** / **Changes** / **Test plan** + - [x] 체크박스 형태를 따름.
제출 결과 — PR #2031
2026-09-20 제출. sweetpark:fix/multi-sql → gluesql/gluesql:main, 변경 파일 2개(core/src/glue.rs, pkg/rust/tests/glue.rs)만 포함된 것 확인.
# 포크에서 upstream으로 PR을 올릴 때는 --repo/--head를 명시해야 안전함
gh pr create --repo gluesql/gluesql \
--base main --head sweetpark:fix/multi-sql \
--title "..." --body "$(cat <<'EOF' ... EOF
)"
# 올린 뒤 실제로 무엇이 올라갔는지 검증 (로컬 도구 설정이 딸려가지 않았는지)
gh pr view 2031 --repo gluesql/gluesql --json files --jq '[.files[].path]'
gh pr checks 2031 --repo gluesql/gluesqlTIP
본문을 heredoc(
<<'EOF')으로 넘기면 백틱·$·줄바꿈이 셸에 먹히지 않음. 따옴표를 이스케이프하느라 본문이 망가지는 사고를 막아줌.
CI 구성 (이 저장소에서 돌아가는 체크들)
Build / Rustfmt / Clippy / coverage / Run tests / Run examples / Run storage tests + 스토리지별 잡(GitStorage, MongoStorage, RedisStorage), 그리고 assign-author와 CodeRabbit 자동 리뷰.
로컬에서는 mongo/redis가 실제 서버를 요구해 돌리지 못했지만 CI에는 그 잡들이 따로 있음 — 로컬 검증 4종(--test glue, gluesql-core, 스토리지 2종, fmt/clippy)으로 커버되는 범위와 CI가 추가로 잡아주는 범위를 구분해 둘 것.
CI 결과 — 14개 체크 전부 pass
Build 1m42s / Rustfmt 27s / Clippy 1m52s
Run tests 3m24s / Run storage tests 3m28s / Run examples 2m21s
Run GitStorage 1m13s / Run MongoStorage 1m57s / Run RedisStorage 1m26s
coverage 7m26s / codecov(patch·project) / coveralls / assign-author
CodeRabbit — Review completed
- 커버리지: patch coverage 34/34 라인 100%, 프로젝트 전체 98.89% 유지(+0.001%). 라인 +24, 히트 +24 — 추가한 코드가 전부 테스트에 닿았다는 뜻
- CodeRabbit:
No actionable comments were generated in the recent review. 🎉— 리뷰 프로필CHILL - coveralls 경고:
Target branch is out-of-sync. Rebase this PR to fix.— base가 main보다 1커밋 뒤처져서 나온 드리프트 경고일 뿐 실패가 아님. 리뷰어가 요청하면 rebase
NOTE
봇 경고 2건 — 무엇을 따르고 무엇을 무시했나
인라인 리뷰는 0건이었지만 체크 레벨 경고가 2건 붙었음. 둘의 성격이 달라서 대응도 갈림.
① Docstring Coverage ⚠️ (60% < 임계치 80%, “5 functions across 2 files”)
지적된 5개의 실체를 확인해보니:
| 파일 | 함수 | docstring |
|---|---|---|
core/src/glue.rs | execute_with_params | 이미 있음(# Errors 포함) |
pkg/rust/tests/glue.rs | basic, batch, redb, memory | 없음 |
→ 부족하다고 지적된 건 전부 통합 테스트 함수. 그런데 저장소 관행이 정반대였음:
find . -name '*.rs' -path '*/tests/*' | wc -l # 82
find . -name '*.rs' -path '*/tests/*' | xargs grep -l '^\s*///' | wc -l # 582개 중 77개가 ///를 안 씀. 게다가 통합 테스트 크레이트의 함수는 public API가 아니라 ///를 붙여도 rustdoc에 렌더링되지 않음. 봇의 임계치가 소스/테스트를 구분하지 않아 생긴 오탐으로 판단 → 임계치 자체는 쫓아가지 않음.
대신 봇 달래기가 아니라 읽는 사람에게 값이 있는 2줄만 추가(redb/memory는 #[test] 진입점일 뿐이라 문서화하면 노이즈):
/// Exercises the public API surface: DDL, DML, SELECT, and `FromGlueRow` conversion.
fn basic<T: GStore + GStoreMut + Planner>(glue: &mut Glue<T>) {
/// Verifies that statements in a single `execute` call are planned against the schema
/// left behind by the statements before them (#2009).
fn batch<T: GStore + GStoreMut + Planner>(glue: &mut Glue<T>) {TIP
봇 경고는 “지시”가 아니라 “신호”로 다룰 것. 임계치를 맞추려고 의미 없는 주석을 채우면 저장소 관행에서 이탈하면서 아무 데도 안 나오는 텍스트만 남음. 근거(관행 통계 + 렌더링 여부)를 들고 따르지 않기로 판단하는 것도 정당한 대응이고, 필요하면 그 판단을 PR 코멘트에 적어두면 됨.
② Build has drifted / Report is 1 commits behind head on main — 이건 따름
base가 upstream main보다 1커밋 뒤처져서 codecov·coveralls가 동시에 경고. 확인해보니 겹치는 파일이 없어 충돌 위험 0이었음:
git fetch https://github.com/gluesql/gluesql.git main
git log --oneline HEAD..FETCH_HEAD # ab43b1a8 Improve README ... (#2029) — README.md만# 로컬 도구 설정이 rebase를 막으므로 잠시 치워둠
git stash push -m "local: graft gitignore" .gitignore
git rebase FETCH_HEAD
git stash pop
# 되돌릴 수 없는 작업이므로 푸시 전에 회귀 검증 (새 base 기준)
cargo test -p gluesql --test glue && cargo test -p gluesql-core
cargo fmt --all -- --check && cargo clippy ... -- -D warnings
git push --force-with-lease origin fix/multi-sqlWARNING
--force가 아니라--force-with-lease. 원격이 내가 마지막으로 본 상태와 다르면(= 그사이 누가 push했으면) 거부됨. 그냥--force는 남의 커밋을 말없이 날림. rebase는 히스토리를 다시 쓰므로 푸시 전에 새 base에서 테스트를 다시 돌리는 게 필수 — base가 바뀌었는데 통과 여부는 옛 base에서만 확인한 상태가 되기 쉬움.
결과: 커밋 2개(Plan each statement... + Document glue test helpers)가 ab43b1a8 위로 재배치, 변경 파일은 여전히 2개 그대로.
🔮 후속 이슈 제안 (별건)
#2009 수정으로 증상은 사라졌지만 근본 설계는 남아 있음. 별도 이슈로 제안할 내용:
- 문제:
fetch_schema_map이 스키마를 못 찾아도 빈 맵 +Ok를 반환해서, “검사 통과”와 “검사 못 함”이 구분되지 않음. 그 결과validate가 조용히 스킵되고 잘못된 쿼리가 성공함. - 근거: 위 실측 결과(수정 전 배치에서
ColumnReferenceAmbiguous가 나지 않음) - 방향 A (좁음): 스키마 부재를 에러로 승격. 변경 폭은 작지만 기존
// ignore rather than returning error의도를 뒤집는 것이라 반대 여지 있음 - 방향 B (넓음): plan 단계에서 식별자를 ordinal로 바인딩해 executor가 이름 해석을 안 하게 함. 버그 클래스 자체가 소멸하지만 리팩터링 규모가 큼
- 제안 형태: A로 올리되 본문에 B를 장기 방향으로 언급
🔗 참고
- GlueSQL Issue #2009 — Statements in a batched execute are planned against the schema from before the batch
- 관련 코드:
core/src/glue.rs(execute_with_params,plan_with_params),pkg/rust/tests/glue.rs
관련 문서
- (Rust) 소유권과 참조(Ownership & Borrowing) - 핵심 개념 및 특징 정리 —
Vec<ParamLiteral>을 루프 밖에서 소유하고¶ms로 빌리는 패턴에 필요 - (Rust) 트레잇과 제네릭(Trait Object, Blanket Impl, 제네릭 경계) - 핵심 개념 및 특징 정리 —
Glue<T: GStore + GStoreMut + Planner>경계가 이미 이 노트의 GlueSQL 실전 예제로 다뤄짐 - (Rust) 이터레이터와 클로저(Iterator Chain & Closure) - 핵심 개념 및 특징 정리 — “모아서 처리 vs 받는 대로 처리” 절에 이 이슈와 동일한 패턴을 정리해둠
-
- [Rust] Decimal 값 동등비교 누락 수정 계획(GlueSQL #1741) — 같은 날 후보로 같이 찾은 이슈
-
- [Rust] Option
TryFrom 매크로 확장 계획(GlueSQL #1389) — 같은 날 후보로 같이 찾은 이슈
- [Rust] Option